![[為你自己寫 Vue Component] AtomicButton](https://ithelp.ithome.com.tw/upload/images/20240917/20120484PS2fJW0nIU.png)
按鈕在網頁中是最常見的元件之一,這個元件通常在使用者點擊後會觸發程式上的操作,可能是關閉或打開 Modal,也可能是送出表單,又或是刪除某些重要資料等等。
在 UI 表現上,按鈕會依照不同的情境而有不同的樣貌。主要按鈕通常會選用填色的實心按鈕,重要程度次之的可能會選用空心透明的按鈕,再次之的按鈕 UI 可能就會是像文字一樣的形式。

在開始實作前,我們先研究各個 UI Library 的 Button 元件設計。
Element Plus

<template>
  <ElButton>Default</ElButton>
  <ElButton type="primary">Primary</ElButton>
  <ElButton type="success">Success</ElButton>
  <ElButton type="info">Info</ElButton>
  <ElButton type="warning">Warning</ElButton>
  <ElButton type="danger">Danger</ElButton>
</template>
Element Plus 的 <ElButton> 提供了非常多樣化的 UI 變化設定,像是可以透過 type 來設定按鈕的顏色,plain 可以設定成 Element 定義的「樸素」按鈕,round 可以設定成圓角按鈕,circle 可以設定成圓形按鈕,size 可以調整按鈕的大小,icon 可以在按鈕前加入 Icon。
Vuetify

<template>
  <VBtn variant="elevated"> Button </VBtn>
  <VBtn variant="flat"> Button </VBtn>
  <VBtn variant="tonal"> Button </VBtn>
  <VBtn variant="outlined"> Button </VBtn>
  <VBtn variant="text"> Button </VBtn>
  <VBtn variant="plain"> Button </VBtn>
</template>
Vuetify 的 <VBtn> 提供了更多的樣式變化,像是 variant 可以設定成 elevated、flat、tonal、outlined、text 與 plain 等等,color 可以設定按鈕的顏色,elevation 的部分可以在 0 到 24 之間設定陰影的深度。
PrimeVue

<template>
  <Button label="Primary" raised />
  <Button label="Secondary" severity="secondary" raised />
  <Button label="Success" severity="success" raised />
  <Button label="Info" severity="info" raised />
  <Button label="Warn" severity="warn" raised />
  <Button label="Help" severity="help" raised />
  <Button label="Danger" severity="danger" raised />
  <Button label="Contrast" severity="contrast" raised />
</template>
PrimeVue 的 <Button> 提供了 severity 來設定按鈕的顏色,raised 可以設定成有陰影的按鈕,rounded 可以將按鈕的外觀設定成圓角較大的按鈕,如果需要的是文字外觀的按鈕,則可以使用 text 做設定。
按鈕的功能很單純,但樣式變化卻非常豐富,幾乎所有的功能都落在 UI 的設定上。在 Element UI 與 PrimeVue 中,要設定按鈕的樣式都是透過特定的屬性來完成。
<!-- Element Plus -->
<ElButton type="primary" round>Primary</ElButton>
<ElButton type="primary" circle>Primary</ElButton>
<ElButton type="primary" text>Primary</ElButton>
<!-- PrimeVue -->
<Button label="Primary" raised />
<Button label="Primary" outlined />
<Button label="Primary" text />
不這些屬性如果同時出現,就要看那個設定的權重比較大了!
<!-- 這會長什麼樣子呢? -->
<ElButton type="primary" round circle />
<Button label="Primary" text outlined /> 
Vuetify 在這個部分比較不會有兩個外觀設定誰的權重比誰大的問題,因為它的設計是透過 variant 來設定按鈕的樣式,這樣一來就不會有兩個屬性的設定衝突問題。
綜合以上並結合自身經驗,我們統整出 <AtomicButton> 的功能:
type、disabled 等等。variant 設定按鈕的「樣式」,像是:實心按鈕(contained)、外框按鈕(outlined)與文字按鈕(text)。color 設定按鈕的「顏色」,常見的有:primary、success、warning、danger 與 info 等等。shape 設定按鈕的「形狀」,預設為有圓角的長方形(rectangle)按鈕,其他還有 circle 與 square 共三種模式。size 依照需求設定按鈕的「大小」,像是:normal 與 small。使用結構如下:
<template>
  <AtomicButton
    variant="contained"
    color="primary"
    size="normal"
    type="button"
    disabled
  >
    按鈕
  </AtomicButton>
</template>
首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
| 屬性 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| variant | contained,outlined,text | contained | 按鈕的樣式 | 
| color | primary,success,warning,danger,info | primary | 按鈕的顏色 | 
| shape | rectangle,circle,square | rectangle | 按鈕的形狀 | 
| size | normal,small | normal | 按鈕的大小 | 
| type | button,submit,reset | button | 原生按鈕的 type 設定 | 
| disabled | boolean | 按鈕是否禁用 | 
interface AtomicButtonProps {
  type?: 'button' | 'submit' | 'reset'
  variant?: 'contained' | 'outlined' | 'text'
  color?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
  size?: 'normal' | 'small'
  shape?: 'rectangle' | 'circle' | 'square'
  disabled?: boolean
}
const props = withDefaults(defineProps<AtomicButtonProps>(), {
  type: 'button',
  variant: 'contained',
  color: 'primary',
  size: 'normal',
})
<AtomicButton> 說起來,除了樣式非常多元外,它是一個實作上相當簡單的元件。
<template>
  <button
    class="atomic-button"
    :disabled="disabled"
    :type="type"
  >
    <span>
      <slot name="default" />
    </span>
  </button>
</template>
先給 <button> 一個基本的樣式,後面我們會透過 props 來設定按鈕的 UI 變化。
$name: '.atomic-button';
#{$name} {
  height: var(--button-size);
  font-size: 0.875rem;
  text-align: center;
  border-style: solid;
  border-width: 1px;
  border-color: transparent;
  outline: none;
  line-height: 1.25rem;
  transition-property: color, background-color, border-color;
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
  transition-duration: 0.15s;
  &:disabled {
    cursor: not-allowed;
  }
}
這裡我們寫一個 computed 將 UI 設定添加到 <button> 上。
const rootClass = computed(() => {
  const BASIC_CLASS = 'atomic-button';
  return [
    `${BASIC_CLASS}--${props.variant}`,
    `${BASIC_CLASS}--${props.color}`,
    `${BASIC_CLASS}--${props.shape}`,
    `${BASIC_CLASS}--${props.size}`,
  ];
});
所以我們可以列出所有需要的 class 名稱
variant: atomic-button--contained, atomic-button--outlined, atomic-button--text
color: atomic-button--primary, atomic-button--success, atomic-button--warning, atomic-button--danger, atomic-button--info
shape: atomic-button--rectangle, atomic-button--circle, atomic-button--square
size: atomic-button--normal, atomic-button--small
這麼多樣式與組合,寫起來應該會很驚人吧!尤其是 color 與 variant 的組合,總共就有 15 種組合,shape 與 size 的組合也有 6 種。
還好我們可以透過 SCSS 的 @each 與 CSS 變數來處理這些變化的組合。
variant 與 color
這裡我們使用 SCSS 的 @each 來處理 variant 與 color 的組合。
$name: '.atomic-button';
#{$name} {
  // variant
  &--contained {
    color: white;
    @each $color, $value in $color-map {
      &#{$name}--#{$color} {
        background-color: rgba($value, 1);
        &:not(:disabled):is(:hover, :focus) {
          background-color: rgba($value, 0.8);
        }
        &:not(:disabled):active {
          background-color: rgba($value, 0.6);
        }
      }
    }
    &:disabled {
      background: lightgray;
    }
  }
}
這樣一來我們就完成了 variant 為 contained 時的 5 種 color 組合,下面是其中一種組合的結果。
.atomic-button--contained {
  color: #fff;
}
.atomic-button--contained.atomic-button--primary {
  background-color: #1976d2;
}
.atomic-button--contained.atomic-button--primary:not(:disabled):is(
    :hover,
    :focus
  ) {
  background-color: #1976d2cc;
}
.atomic-button--contained.atomic-button--primary:not(:disabled):active {
  background-color: #1976d299;
}
$color-map是一個我們自定義的 SCSS 變數,裡面包含了各種顏色的名稱與對應的色碼。$color-map: ( primary: #1976D2, success: #72BF24, warning: #FFAD0F, danger: #E52D27, info: #909399 );
size 與 shape
這裡我們使用 CSS 的變數來處理 size 與 shape 的組合。
$name: '.atomic-button';
#{$name} {
  height: var(--button-size);
  // size
  &--normal {
    --button-size: 36px;
    --button-padding: 20px;
  }
  &--small {
    --button-size: 32px;
    --button-padding: 10px;
  }
  // shape
  &--rectangle {
    padding-right: var(--button-padding);
    padding-left: var(--button-padding);
    border-radius: 6px;
  }
  &--square {
    width: var(--button-size);
    border-radius: 6px;
  }
  &--circle {
    width: var(--button-size);
    border-radius: 9999px;
  }
}
應用了 CSS 的變數功能,我們只在 size 的 class 設定好變數,而在 shape 的 class 裡面就可以直接使用這些變數。原本要寫六種組合的 CSS 現在只需要寫五種就好了,size 或 shape 選擇越多,能省下的 CSS 就越多。
樣式搞定了,接下來我們處理 Icon 的部分。關於 Icon 我們有幾種設定方式可以考慮:
slot 設定 Icon 內容。使用 props 傳入 Icon 名稱的方式可以這樣做:
<AtomicButton icon="add">新增</AtomicButton>
這種做法在 <AtomicButton> 內部可以選擇使用 CSS 實作或是依照傳入的屬性去取得對應的 Icon 元件。前者我們需要在元件內部或另外維護一包 Icon 的 CSS,後者則是需要建立名稱與元件的對應表。
如果能讓 Icon 的設定與 <AtomicButton> 脫鉤那就更好了。這樣就不需要在元件內部維護 Icon 的 CSS 或建立對應表了。
使用 props 傳入 Icon 元件的方式可以這樣做:
<template>
  <AtomicButton :icon="AddSvg">新增</AtomicButton>
</template>
這裏使用 vite-svg-loader 來載入 SVG 這樣我們就可以直接將 SVG 當作元件來使用。
這樣一來我們就讓 Icon 與 <AtomicButton> 完全解耦,而且這樣的設計讓我們可以更容易地擴充 Icon。但有些人可能覺得如果想要調整 Icon 的樣式就會變得比較麻煩,這時候我們可以再傳入 iconProps 作爲 Icon 元件的 props,或是我們還有其他選擇。
透過 slot 設定 Icon 的話我們可以這樣做:
<template>
  <AtomicButton>
    <template #prepend>
      <AddSvg fill="currentColor" />
    </template>
    新增
  </AtomicButton>
</template>
這樣一來我們就能夠更自由地設定 Icon 與其樣式,使用起來更加彈性與方便,缺點則是使用上可能會讓畫面稍微雜亂一點。
我自己偏好使用 slot 做設定,雖然使用上會略顯雜亂,但卻是最彈性的方式。
<template>
  <button
    class="atomic-button"
    :class="rootClass"
    :disabled="disabled"
    :type="type"
  >
    <span v-if="$slots.prepend">
      <slot name="prepend" />
    </span>
    <span>
      <slot name="default" />
    </span>
    <span v-if="$slots.append">
      <slot name="append" />
    </span>
  </button>
</template>
這裏我們還加上了 named slot 是否有被使用的判斷,像是上面的範例有使用到 prepend slot,我們才把該層的 <span> 給渲染出來。這一來可以避免渲染無用的結構,還可以幫助我們更方便地定義按鈕內元素的間距。
$name: '.atomic-button';
#{$name} {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  column-gap: 10px;
}
這樣一來我們就完成了 <AtomicButton> 元件,現在可以依照不同的場景任意切換按鈕的樣式了。

我們經常看到一些 UI 呈現上與按鈕一樣,但實際上點擊後會像超連結一樣換頁,當然我們可以使用 router.push() 來完成這個功能,但如果考量到語意化標籤的使用、無障礙或是 SEO 的考量,<AtomicButton> 元件如果能支援渲染成 <a> 標籤那就更好了。
所以我們讓 <AtomicButton> 也可以接收 to 這個屬性,在使用時沒有傳入 to 的話就維持使用 <button>,反之有傳入 to 時則改用 <AtomicLink>。
interface AtomicButtonProps {
  // 如果有傳入 `to`,內部會渲染 `<AtomicLink :to="to" />`
  to?: RouteLocationRaw
}
const props = withDefaults(defineProps<AtomicButtonProps>(), {
  // 略
  to: undefined,
})
const rootComponent = computed(() => {
  return props.to == null ? 'button' : AtomicLink
})
<template>
  <component
    :is="rootComponent"
    class="atomic-button"
    :class="rootClass"
    :disabled="rootComponent === 'button' ? disabled : undefined"
    :to="to"
    :type="rootComponent === 'button' ? type : undefined"
  >
    <!-- 略 -->
  </component>
</template>
這樣一來我們就可以在按鈕與連結之間切換,卻仍然擁有相同的 UI 表現了!
<a>元素不支援disabled,所以在這裡只有當rootComponent為 button 時才加上disabled的設定。如果是渲染成<AtomicLink>則永遠傳入undefined。
很多人習慣使用 <div> 或 <span> 並在上面綁定點擊事件作為按鈕使用,因為這樣不用處理跨瀏覽器上的按鈕樣式不統一問題。這乍看之下沒有什麼問題,一樣可以點擊、一樣可以結案,但其實使用這種方式做成的按鈕功能並不完整。
首先,當滑鼠滑到原生按鈕上方時,游標會從箭頭變成「手指頭」。另外,原生的按鈕元件除了可以點擊之外,還可以透過 tab 鍵做焦點的切換,並且可以透過按下 space 跟 enter 鍵觸發 click 事件。
所以在使用上能選用 <button> 作為按鈕時就盡量不要使用其他的元素替代。如有需要使用像是 <div> 等其他元素替代時,則也需要讓該元素符合上述的各種條件。
以下是如果需要用非 <button> 元素實作按鈕功能建議要加上的屬性,如果有需要可以作為參考使用。
<template>
  <div
    role="button"
    tabindex="0"
    @click="onButtonClick"
    @keydown="onButtonKeydown"
  >
    我是一個按鈕
  </div>
</template>
另外順帶一提,與 <button> 元素不同,<a> 元素只支援 enter 觸發 click 事件,在實作上不妨多留意兩者之間些微的不同。
<AtomicButton> 我們幾乎沒有處理關於「功能」方面的程式碼,而是聚焦在如何處理各種變化組合的按鈕。也因為我們是繼承 HTML 中的 <button> 元素,因此按鈕本身可以支援的屬性,元件也都支援。
我們唯一處理的功能是在傳入 to 的時候會自動切換使用 <AtomicLink> 但擁有一樣的 UI 樣式。在遇到長得像按鈕的連結時其實非常好用。
無障礙部分提供給需要用其他元素替代原生按鈕元素的讀者參考。按鈕是網頁開發中最常用的元件之一,雖然很簡單實作,但也是有一些小細節需要注意。如果能把無障礙的部分也照顧好,才能稱得上是使用者友善的網站吧!
<AtomicButton> 原始碼:AtomicButton.vue